import type { AxiosInstance, AxiosRequestConfig, AxiosRequestHeaders, AxiosResponse } from 'axios';
import axios from 'axios';
import { commonUtils, stringUtils } from 'my-ts-core';
import type { StringKeyObject } from 'my-ts-core/types';

import { AuthenticationService, Token, TokenService } from '../auth';
import { User } from '../auth/user';
import { ConfigService } from '../config';
import type { ErrorDetails } from '../interfaces';
import { LocalGeneralConfig } from '../models';
import { HttpMethod } from './http-method';
import type { HttpRequestParameter } from './http-request-parameter';
import { HttpResponseCodeManager } from './http-response-code-manager';
import { HttpResponseResult } from './http-response-result';
import { HttpStatus } from './http-status-code';

export class HttpRequestService {
  protected static httpClient: AxiosInstance;
  protected static localGeneralConfig: LocalGeneralConfig;
  protected static refreshingTokenPromise: Promise<void> | null;

  constructor(config?: AxiosRequestConfig) {
    if (!HttpRequestService.httpClient) {
      const defaultConfig: AxiosRequestConfig = {
        timeout: commonUtils.isDevEnv() ? 99999 : 2000,
        withCredentials: true,
      };

      HttpRequestService.httpClient = axios.create({
        ...defaultConfig,
        ...config, // user-supplied config overrides defaults
      });
    }
  }

  /**
   * Combines the provided base url and relative url.
   * Does NOT use HttpInterceptor to achieve this goal because we want to
   * make the code logic clear and in one place.
   * @param baseUrl The base url for current http request.
   * @param url The relative url string.
   * @returns The full url.
   */
  combineUrl(baseUrl: string, url: string): string {
    if (baseUrl && baseUrl !== '') {
      // Converts the input url to lower case just in case.
      // url = url.toLowerCase();

      // Checks if the input url already has a leading slash.
      if (!url.startsWith('/')) {
        // Appends the leading slash if needed.
        url = '/' + url;
      }

      // Checks if the input url already starts with the base url.
      if (url.startsWith(baseUrl)) {
        // If already contains the base url.
        return url;
      } else {
        // Appends the base url if needed.
        return baseUrl + url;
      }
    } else {
      return url;
    }
  }

  /**
   * Combines the relative url with default base url.
   * @param url The relative url string.
   * @returns The full url.
   */
  async combineUrlWithDefault(url: string): Promise<string> {
    await this.loadLocalGeneralConfig();
    return this.combineUrl(HttpRequestService.localGeneralConfig.httpBaseUrl, url);
  }

  /**
   * Makes the original http request based on [httpMethod] which does not attach any default header and parameter.
   * This is suitable for loading local json file or making a request to another remote system url.
   */
  async makeOriginalRequest(
    url: string,
    param: HttpRequestParameter = {},
  ): Promise<HttpResponseResult> {
    const { baseUrl, header, httpMethod, data = {} } = param;

    // Making original request usually used to load json config file.
    // That's why we cannot read config from json file here.
    // Otherwise recursive error happens because ConfigService uses makeOriginalRequest to load json file.
    const config: AxiosRequestConfig = {
      baseURL: baseUrl,
      headers: header,
      url,
      method: httpMethod,
    };

    // For GET requests, use `params`; otherwise, use `data`
    if (httpMethod === HttpMethod.get) {
      config.params = data;
    } else {
      config.data = data;
    }

    let response: AxiosResponse;

    try {
      response = await HttpRequestService.httpClient(config);
    } catch (e: any) {
      response = e.response;
    }

    return {
      success: true,
      payload: response!.data,
    };
  }

  /**
   * Gets the remote server data.
   */
  async get(url: string, param: HttpRequestParameter = {}): Promise<HttpResponseResult> {
    param.httpMethod = HttpMethod.get;
    return this.makeRequestWithTokenCheck(url, param);
  }

  /**
   * Posts the data to the remote server.
   */
  async post(url: string, param: HttpRequestParameter = {}): Promise<HttpResponseResult> {
    param.httpMethod = HttpMethod.post;
    return this.makeRequestWithTokenCheck(url, param);
  }

  /**
   * Puts the data to the remote server.
   */
  async put(url: string, param: HttpRequestParameter = {}): Promise<HttpResponseResult> {
    param.httpMethod = HttpMethod.put;
    return this.makeRequestWithTokenCheck(url, param);
  }

  /**
   * Patches the data to the remote server.
   */
  async patch(url: string, param: HttpRequestParameter = {}): Promise<HttpResponseResult> {
    param.httpMethod = HttpMethod.patch;
    return this.makeRequestWithTokenCheck(url, param);
  }

  /**
   * Delete the data on the remote server.
   */
  async delete(url: string, param: HttpRequestParameter = {}): Promise<HttpResponseResult> {
    param.httpMethod = HttpMethod.delete;
    return this.makeRequestWithTokenCheck(url, param);
  }

  async upload(url: string, param: HttpRequestParameter): Promise<AxiosResponse | null> {
    param.baseUrl ??= HttpRequestService.localGeneralConfig.httpBaseUrl;
    const config = this.getAxiosRequestConfig(url, param);

    config.headers = {
      ...config.headers,
      'Content-Type': 'multipart/form-data',
    };

    let response: AxiosResponse | null = null;

    try {
      const formData = new FormData();

      // For upload purpose we need to put file/file array in data parameter as well as other necessary parameters.
      // That's why we check on array type and we take key as upload parameter name which will be referenced by backend api.
      for (const key in param.data) {
        if (Array.isArray(param.data[key])) {
          for (const item of param.data[key]) {
            formData.append(key, item);
          }
        } else {
          formData.append(key, param.data[key]);
        }
      }

      response = await HttpRequestService.httpClient.post(url, formData, config);
    } catch (e: any) {
      response = e.response;
    }

    return response;
  }

  async download(url: string, param: HttpRequestParameter): Promise<AxiosResponse | null> {
    param.baseUrl ??= HttpRequestService.localGeneralConfig.httpBaseUrl;

    const config = this.getAxiosRequestConfig(url, param);
    config.responseType = 'blob';

    let response: AxiosResponse | null = null;

    try {
      response = await HttpRequestService.httpClient(config);

      // Response example:
      // content-disposition: "attachment; filename=book.csv"
      const filename = response!.headers['content-disposition'].substring(
        response!.headers['content-disposition'].indexOf('=') + 1,
      );

      const href = window.URL.createObjectURL(response!.data);
      const anchorElement = document.createElement('a');
      anchorElement.href = href;
      anchorElement.download = filename;
      document.body.appendChild(anchorElement);
      anchorElement.click();
      document.body.removeChild(anchorElement);
      window.URL.revokeObjectURL(href);
    } catch (e: any) {
      response = e.response;
    }

    return response;
  }

  /**
   * Gets common AxiosRequestConfig.
   */
  protected getAxiosRequestBaseConfig(): AxiosRequestConfig {
    const configuredTimeout =
      HttpRequestService.localGeneralConfig.httpRequestTimeoutInSecond * 1000;

    // https://vitejs.dev/guide/env-and-mode.html
    // Defines when http request should raise timeout exception.
    // The short timeout duration works in release mode only.
    return {
      timeout: commonUtils.isDevEnv() ? 99999 : configuredTimeout,
    };
  }

  /**
   * Gets AxiosRequestConfig based on [url] and [param].
   */
  protected getAxiosRequestConfig(url: string, param: HttpRequestParameter): AxiosRequestConfig {
    const { baseUrl, httpMethod, data } = param;

    // Adds or overwrites with custom headers.
    const header = param.header
      ? { ...this.createRequestHeader(), ...param.header }
      : this.createRequestHeader();

    // https://vitejs.dev/guide/env-and-mode.html
    // Defines when http request should raise timeout exception.
    // The short timeout duration works in release mode only.
    const config: AxiosRequestConfig = {
      ...this.getAxiosRequestBaseConfig(),
      baseURL: baseUrl,
      headers: header,
      url,
      method: httpMethod,
    };

    if (httpMethod === HttpMethod.get) {
      config.params = data;
    } else {
      config.data = data;
    }

    return config;
  }

  /**
   * Loads base url from config if needed.
   */
  protected async loadLocalGeneralConfig(): Promise<void> {
    HttpRequestService.localGeneralConfig = await ConfigService.getLocalGeneralConfig(
      new LocalGeneralConfig(),
    );
  }

  /**
   * Creates the authroization http request header.
   */
  protected createAuthorizationRequestHeader(): AxiosRequestHeaders {
    const header: Record<string, any> = {};

    if (TokenService.hasAccessToken) {
      header['Authorization'] = `${TokenService.bearer} ${TokenService.activeAccessToken}`;
    }

    return header as AxiosRequestHeaders;
  }

  /**
   * Creates the customized http request header.
   */
  protected createRequestHeader(): AxiosRequestHeaders {
    const header = this.createAuthorizationRequestHeader();
    header['Content-Type'] = 'application/json; charset=utf-8';

    return header as AxiosRequestHeaders;
  }

  /**
   * Check if need to refresh the token based on the current requesting url.
   * @param url The string of current requesting url.
   * @returns True means need to refresh the token. False means NO such need.
   */
  protected async needToRefreshToken(url: string): Promise<boolean> {
    return (
      HttpRequestService.localGeneralConfig.noTokenRefreshNeedUrls.filter((p: any) =>
        url.endsWith(p),
      ).length === 0
    );
  }

  /**
   * Makes the http request.
   */
  protected async makeRequest(
    url: string,
    param: HttpRequestParameter,
  ): Promise<AxiosResponse | null> {
    const config = this.getAxiosRequestConfig(url, param);
    let response: AxiosResponse | null = null;

    try {
      response = await HttpRequestService.httpClient(config);
    } catch (e: any) {
      response = e.response;
    }

    return response;
  }

  /**
   * Processes the http request by attaching it with other operators. This method will check the local token.
   * If the local access token expired the refresh token request will be raised before the user request.
   * Once the new access token is retrieved the user request will contiune.
   */
  protected async makeRequestWithTokenCheck(
    url: string,
    param: HttpRequestParameter,
  ): Promise<HttpResponseResult> {
    await this.loadLocalGeneralConfig();
    param.baseUrl ??= HttpRequestService.localGeneralConfig.httpBaseUrl;

    let isRefreshingTokenSuccessful = false;
    let response: AxiosResponse | null;

    if ((await this.needToRefreshToken(url)) && TokenService.isAccessTokenExpired) {
      if (!HttpRequestService.refreshingTokenPromise) {
        HttpRequestService.refreshingTokenPromise = new Promise(async (resolve) => {
          try {
            // Tries to refresh the token.
            isRefreshingTokenSuccessful = await this.refreshToken();

            return resolve();
          } finally {
            HttpRequestService.refreshingTokenPromise = null;
          }
        });

        await HttpRequestService.refreshingTokenPromise;

        if (isRefreshingTokenSuccessful) {
          // If refreshing token process is done then we nee to retry the original request.
          return this.makeRequestWithTokenCheck(url, param);
        } else {
          return this.checkHttpResponse(response!);
        }
      } else {
        // console.info('Waiting for refreshing token ...');

        await HttpRequestService.refreshingTokenPromise;

        // Continues the original request.
        return this.makeRequestWithTokenCheck(url, param);
      }
    } else {
      response = await this.makeRequest(url, param);

      return this.checkHttpResponse(response);
    }
  }

  /**
   * Refreshes the token if it can. It returns [true] if the refreshing is successful.
   */
  protected async refreshToken(): Promise<boolean> {
    // The uri for refreshing token.
    const url = await this.combineUrlWithDefault('/authentication/refresh-token');

    // TODO: Applies real deviceInfo & systemInfo as needed.
    // Prepares the post body.
    const body = {
      deviceInfo: {},
      systemInfo: {},
    };

    // If the refreshing token process is successful.
    let isRefreshingTokenSuccessful = false;

    let response: AxiosResponse;

    try {
      const config = this.getAxiosRequestBaseConfig();

      // Makes http post to refresh the token.
      response = await HttpRequestService.httpClient.post(url, body, config);
      const result = this.checkHttpResponse(response);

      if (result.success) {
        // Gets the server data to user instance.
        const user = User.fromJSON(response.data.payload);

        // Saves the user data retrieved from server.
        new AuthenticationService().saveUserInfo(user);

        const token = await Token.fromServerJSON(response.data.payload);

        // Sets the new token retrieved from remote server.
        TokenService.save(token);

        // Just in case user info has been changed since last login.
        this.notifyCurrentUserChange(AuthenticationService.currentUser);

        isRefreshingTokenSuccessful = true;

        // console.info('Token refreshed');
      }
    } catch (e) {
      // We don't need to handle exception here. The original request will do. Such like network connection issue.
      // We just make sure this function returns without being interrupted by exception.
    }

    return isRefreshingTokenSuccessful;
  }

  /**
   * Checks on [response] to see if it is successful or not.
   *
   * If it is successful then we will convert it to object.
   * If the status code is [HttpStatus.unauthorized] we will try to refresh the access token if we can.
   */
  protected checkHttpResponse(response: AxiosResponse | null): HttpResponseResult {
    let httpResponseResult: HttpResponseResult;

    // Checks if the response is successful or not.
    switch (response?.status) {
      case HttpStatus.ok:
      case HttpStatus.noContent:
        httpResponseResult = response.data;
        break;
      case HttpStatus.unauthorized:
        this.handleUnauthorizedError(response);
        break;
      case HttpStatus.forbidden:
        this.showMessage('抱歉，您被禁止该访问。');
        break;
      case HttpStatus.notFound:
        this.showMessage('当前服务不可访问');
        break;
      default:
        this.handleGeneralError(response!);
        break;
    }

    httpResponseResult ??= new HttpResponseResult(false);

    return httpResponseResult;
  }

  /**
   * By now the spring security rest returns [HttpStatus.unauthorized] for different reasons.
   * We need to check into the header to see what exactly happened.
   * Handles the error of the [HttpStatus.unauthorized].
   */
  protected handleUnauthorizedError(response: AxiosResponse): void {
    const errorDetails = this.getBodyErrorDetails(response) ?? this.getHeaderErrorDetails(response);

    if (!stringUtils.isNullOrUndefinedOrEmptyString(errorDetails?.message)) {
      // Displays message to user.
      // Actually we did not display message like 'user not found' or 'password is incorrect'.
      // This is for void brute force attack. If the attacker does NOT know the login id or password is correct that would make the attack more difficult.
      this.showMessage(errorDetails!.message!);
    } else {
      this.showMessage('登录已过期或没有访问权限，请重新登录。');
    }

    this.logUserOut();
  }

  /**
   * By now the spring security rest returns [HttpStatus.unauthorized] for different reasons.
   * We need to check into the header to see what exactly happened.
   * Handles the error of the [HttpStatus.unauthorized].
   */
  protected handleGeneralError(response: AxiosResponse): void {
    const errorDetails = this.getBodyErrorDetails(response) ?? this.getHeaderErrorDetails(response);
    let errorMessage = '服务处理发生问题，可能网络或服务不可用，或服务出现未知情况。';

    if (!stringUtils.isNullOrUndefinedOrEmptyString(errorDetails?.message)) {
      errorMessage += `: ${errorDetails?.message}。`;
    }

    this.showMessage(errorMessage);
  }

  /**
   * Gets the response error from body.
   */
  protected getBodyErrorDetails(response?: AxiosResponse): ErrorDetails | null {
    return this.getErrorDetails('message', response?.data);
  }

  /**
   * Gets the response error from header.
   */
  protected getHeaderErrorDetails(response?: AxiosResponse): ErrorDetails | null {
    return this.getErrorDetails('Error-Code', response?.headers);
  }

  /**
   * Gets the error message from [data] which might be the response body or header.
   * The [key] which used to find in the map to see if it contains error code.
   */
  protected getErrorDetails(key: string, data?: StringKeyObject | null): ErrorDetails | null {
    if (data) {
      let errorMessage: string | null;
      let errorCode: string | null;

      // Tries to get the error key from http header if the header key contains [key].
      // Uses orElse parameter to silent 'No Element' error if no match found.
      const errorKey = Object.keys(data).find((p) => p.toLowerCase().includes(key));

      // If error code exists.
      if (!stringUtils.isNullOrUndefinedOrEmptyString(errorKey)) {
        // Gets the code by the key.
        errorCode = data[errorKey!];

        // Gets the message by code.
        errorMessage = HttpResponseCodeManager.getCodeMessage(errorCode!);

        return {
          code: errorCode,
          message: errorMessage,
        };
      }
    }

    return null;
  }

  /**
   * Notifies the change of current user.
   * Sub-class overridden is needed.
   */
  protected notifyCurrentUserChange(_user: User | null): void {}

  /**
   * Shows message to user if anything goes wrong.
   * Sub-class overridden is needed.
   * @param msg
   */
  protected showMessage(msg: string): void {
    console.error(msg);
  }

  /**
   * Logs current user out.
   * Sub-class overridden is needed.
   */
  protected logUserOut(): void {}
}
